领域驱动点播直播弹幕业务合并设计实践
本期作者
孙嘉岐
哔哩哔哩资深开发工程师
为什么要做DDD
业务成长之痛
你的业务是否存在以下问题?
每个业务强依赖几个牛逼的领域工程专家维护,接受新业务或新人上手时经验无法复用,需要重新熟悉几周甚至几个月。
一些迭代不频繁的业务场景自己半年前的代码也很难看懂,偶尔迭代一下要熟悉很久,需要深入到编码细节才能理解业务过程。
一旦出现项目交接痛不欲生,在不足文档、没有人讲的情况下效率极低,得把之前别人的坑重新踩一遍,通过经验培养出新的领域工程专家。
随着需求变多项目越来越臃肿,复杂度指数增加,事故频发,开发效率不断下降。
上面的问题可以归结为,缺少系统设计过的微服务框架和业务开发模式、缺少研发规范、面向过程编程。
在业务早期阶段,一个人长期负责一个项目,系统复杂度也不高。上述问题在这一阶段不重要,过度强调设计模式和规范反而会降低效率,影响业务快速启动。
然而当业务逐渐趋向成熟,出现部分业务必须多人合作,同时一个人又需要负责多个业务的情形下,高效上手、交接、维护、协作成了必选项,以上问题亟待解决。
复杂度增长模式
业务代码的复杂度是怎么增加的呢?随着业务持续迭代,代码实现业务能力、基础组件、中间件的复用,从而降低系统长期迭代产生的复杂度复杂度增加可能出现的三种常见模式:
耦合模式
随着业务需求增加,代码复杂度指数提升。
业务逻辑无法内聚,后面写的功能需要在之前不相关的代码模块里加些特殊逻辑。
发展一段时间之后,新写一个需求没有人能搞清楚系统中哪里会出点bug,每改一点都需要对系统进行全局测试。导致无法保证稳定性、无法持续迭代,整体呈混沌状态。
烟囱模式
随着业务需求增加,代码复杂度线性提升。
业务逻辑无法复用,每次新需求都要从头写一整套,出现大量复制粘贴的重复代码。
能低效支持业务迭代,但很难做微服务以及业务范围的基础组件升级、业务模块升级。因为分布在垂直烟囱结构当中,每一个功能模块都独立评估、更新、测试、监控,代价过高。同时交接、多人协作成本巨大。
迭代几年之后,业务逻辑、历史坑、特殊逻辑会积累很多。交接时前团队不可能在一次讲全,接手团队也不可能把每一块细节代码全看一遍。无法快速有效获取系统的整体认知,只能靠探地雷式重新踩坑来获得领域知识。
复用模式
随着业务需求增加,代码复杂度对数提升。
如果希望随着业务复杂度增加代码复杂度增长低于线性,那么必须通过复用相对静态的基建来承接动态的业务需求。
DDD的核心目标是解决软件开发中的业务复杂度,通过架构分层、领域拆分和建模等手段实现关注点分离,提升代码自身的表达和约束能力,从而提升整体效率。
这里的具体思路是:
通过战略设计来划分领域上下文,实现领域的内聚和对外解耦。
微服务架构选型上基于DDD的分层架构和六边形架构来清晰定义职责边界,层级间实现可插拔。
领域建模上抽象出可复用、符合业务长期迭代方向的业务模型。
这样,通过相对静态的领域划分、微服务架构和业务模型来实现业务能力、基础组件、中间件的复用来承接不断动态迭代的业务需求,从而降低系统长期迭代产生的复杂度。
业务架构设计
业务背景
在具体介绍业务应用前先来看一下业务背景。因历史原因,点播和直播的弹幕平台由不同团队独立设计开发,业务逻辑、整体架构、代码框架、中间件、公共库等方面都有较大差异。一定程度上的重复建设导致了研发、产品、运维等多个团队都存在效能浪费。
基于以上契机,我们根据DDD的战略设计来整合点播、直播两侧的弹幕平台,依据职能来划分业务子领域并定义领域上下文。在微服务架构层面,通过DDD的战术设计来实现框架结构统一、多层级关注点分离、领域模型充血。最终落地建设具备统一平台能力、服务于多业务场景的弹幕平台。
设计思路
合并前的业务架构
合并后的业务架构
网关层
整合后的弹幕平台网关在协议上统一了核心模块的DTO模型,当前直播侧的app和web网关服务做消息透传。
同理,移动端和web端的弹幕消息也由直播模块做透传转发不做解析。这样最终实现弹幕业务逻辑在各端内聚收拢。
业务层
除基础的发送、展示能力外,弹幕业务领域也包含活动特效,投票、评分等互动组件,直播侧的弹幕表情,以及相关的策略和底层能力。
在业务层根据战略设计,拆分出互动弹幕服务、弹幕活动平台等几个微服务,同时支持点播和直播业务。
原直播业务的基础功能、表情等业务模块逐步合并到点播侧,实现领域内聚。
平台组件层
直播侧的弹幕系统同时承载着整体直播业务的房间消息广播的通道职责,例如送礼,用户权益升级等。这里我们对业务和长链广播通道做了微服务拆分,新的设计中直播长链广播模块下沉到直播侧平台组件层,保留给其他业务发送房间广播的通道能力。
此外,通过业务层的领域合并,实现了对外部平台组件的调用收敛。
业务层战略设计
弹幕推荐
面向C端的展示场景,包含弹幕个性化推荐的多组模型索引,数据更新、回刷,淘汰策略等。
核心交互
面向C端的交互场景,如展示和发送。
弹幕基础
视频稿件维度的整体弹幕信息称为弹幕池,包含稿件id,弹幕池状态,计数,分段上限等。
弹幕的基础信息包括内容,状态,颜色,字体,位置,时间点,发送人id,属性位等。
这个通用子域包含上述弹幕池和弹幕的领域模型,以及充血模型的状态校验、数据强相关的业务逻辑等。
只有通用子域的领域模型允许共享给其他子域,需要严格定义准入边界,防止无序扩展。
活动特效
弹幕的展示特效,如跨晚烟花等。包含C端展示,以及创作端、运营配置能力。
互动玩法
基于弹幕形式拓展出的,如投票、评分、关注卡片、预约卡片等。包含C端展示和创作端配置能力。
策略管控
应用于多个场景的底层策略能力,如内容聚集识别、同质化信息打薄等,以离线计算为主。
运营后台
面向内部的弹幕管理后台,主要包含查询、信息变更、活动配置等能力。
核心收益
通过业务架构的合并,团队获得的收益是:
通过子领域合并从烟囱模式优化到了复用模式。例如跨年晚会等场景点播、直播需要开发同样的特效能力,可减少一半开发量。
实现了弹幕领域下点播和直播的能力复用,如投票、评分等基础业务组件能力,以及底层策略识别、内容管控能力。
降低产研运多团队业务认知复杂度,降低日常维护、管控、投放成本。
微服务架构设计
工具选择
这里我们先调研了DDD,六边形架构,CQRS,Event Sourcing等架构模式。最终我们选择微服务框架的主体采用六边形架构和DDD的结合,同时将CQRS和DDD的一些核心概念应用到编码规范层面。
设计模式 | 应用维度 | 说明 |
DDD分层架构 | 微服务框架,DTO/DO/PO模型分离,领域服务拆分(如有必要) | 通过拆分模型强制实现逻辑分层。 |
六边形架构 | 微服务框架,结合DDD数据模型实现分层 | 和DDD结合,比原生的分层架构层级间隔离更强制、更彻底。 |
CQRS | 接口设计规范 | 拆分读写模型更适合B端的复杂业务链路,对C端业务来说有些over design。因为kratos天生带job,大部分业务不缺柔性设计。应用到服务接口/分层的对外接口层面即可。 |
充血模型、显示设计、声明式设计 | 设计、代码编写规范 | 显示设计、声明式设计能显著增加代码可读性。充血模型大大提升复用能力。 |
领域模型定义,Entity, Value Object, Aggregation, Aggregation Root | 设计、编码层面作为good to have, 不强制要求 | 对于C端业务,业务模型的复杂程度还好。DDD本身需要团队大量精力去在微服务架构、代码风格等方面建立共识,这块适合作为good to have降低团队理解成本。 |
基于Kratos的六边形架构改造
理想的六边形架构
六边形架构的核心是,将系统的输入端(Driving Side)和输出端(Driven Side)通过可插拔设计和业务领域层隔离开,从而实现领域层只关注业务逻辑。
Kratos的结合和妥协
Kratos脚手架的基础设定是,同一个业务根据水平分层拆分出interface, job, service三个微服务,分别负责网关、异步消息消费、业务逻辑。部分业务在实际演进中还额外拆分了admin和dao两个微服务,负责管理后台和数据读取。
根据水平层级拆分微服务,在业务早期提供了快速启动上手等优势,同时将异步任务独立拆分出job服务也让系统不缺少柔性设计。
然而随着业务演进,这套架构出现了以下问题:
同一个业务领域内逻辑无法内聚,经常出现在interface, job, service中出现职责重复的业务模块。
缺少部门、公司范围内的统一代码结构,各业务应用kratos的方式不尽相同,对interface, job, service的边界定义不统一。
长链路出现非必要的节点多跳,在资源使用、维护管理上都带来额外成本。
同时,基于kratos应用六边形架构时的问题是没法实现接口的可插拔。核心业务逻辑被通过同步、异步的方式拆分在service, job两个微服务中,没法内聚在一个微服务的领域层里。那怎么办?
历史上,我们出现了两种模式。一种是到处抄代码,问题显而易见。一种是抽象出common包,但导致了无序扩展、缺少约束的问题:
一开始,interface, job, service共用一些common包里面的数据模型和dao层方法,这没什么问题。
后来随着业务扩展,业务范围内出现了2个interface,8个service,5个job共用common包下的数据模型和持久层代码,这就问题很大。
每次改了common包下的东西不知道发哪些服务,导致了一些线上问题;同时因为缺少边界划分,common包内的代码不断膨胀,难以治理。
那有没有解决办法?
改造:common包、内部接口
我们通过common包的改造和定义领域内部接口的方式来解决这些问题。
首先通过DDD的战略设计划分子领域,允许子领域内部的common包共享业务模型和持久层方法。业务公有的common包内只允许出现如constants, utils等静态代码。
子领域内的common包可共享
DO和PO对象,以及充血模型的自带的成员函数。(这也是使用充血模型的好处之一,在这个场景下贫血模型只能共享业务代码,或者重复写。)
repo和gateway层级,对底层infra或接口进行封装。一个子领域内的service和job可以共享数据存储,但对子领域外的应用隔离。
子领域内的common包不可共享
Service层的核心业务逻辑。如果同一个业务功能既可以同步触发也可以异步出发,则让job调用service接口。
Dao层的数据聚合、封装逻辑。
子领域内的接口共享
在service的api层拆分出独立的内部领域服务仅供job使用,并和对外接口隔离。例如:
接口api示例代码
service Activity {
rpc AddDmActivity(AddDmActivityReq) returns (AddDmActivityResp); //添加活动
// ...
}
service Internal {
rpc FlushDmActivityCache(FlushDmActivityCacheReq) returns (FlushDmActivityCacheResp); //删除活动缓存
// ...
}
这些领域内部接口只允许同一个领域的job调用,所以可以接受不遵循声明式设计的命名方式,以执行逻辑命名。对外接口则严格不允许。
代码分层结构
这个微服务架构结合了DDD的分层架构和六边形架构。它主要达成以下目标:
各层级明确定义职责边界,防止层级间的耦合和职责泄露。
通过数据模型的应用范围进行层级间的强隔离。
层级接口要求
每个层级通过Interface统一定义对其他层级暴露的公开接口,Handler层调用定义在Service层的接口,Dao层面向Service层设计接口并实现,实现六边形架构中driving side和driven side的可插拔设计。
在Service层和Dao层定义的接口中,输入输出统一使用DO,从而保证领域层只使用DO对象,核心业务逻辑不会被其他层级的复杂度入侵。
领域模型要求(充血模型)
DO对象不允许出现extra, json这类开放式、弱校验的数据形式。字段定义必须清晰、可校验。
在DO的成员函数中定义validate方法。在mapper进行DTO/PO对象转换成DO对象,或自身更新时强制进行校验,这样可以保证领域对象永远处于合法状态。
DO的成员函数中也包含强数据相关的业务逻辑,实现充血复用。
代码分包结构
业务子领域结构
微服务结构
代码示例
子领域业务概况
介绍一下简化版的业务情况以方便理解:
弹幕活动(Activity)是领域的核心对象,标识一次特效活动。
每次活动可以选择一种投放类型,例如全站投放、按直播间id投放、按视频id投放、按up主id投放等,通过Type标识。
选择一种投放类型后可以配置多个投放维度(ActivityDimension),例如投放类型是视频,则可以细化选择每个视频下面具体生效的时间段。
每次活动需要选取1中特效,定义在动态资源(DynamicResource)中。每种不同的特效类型所需要的资源配置不尽相同。
最终形成的映射关系是:Activity → ActivityDimension 一对多,一次活动可以投放多个维度。Activity → DynamicResource 多对一,一次活动仅能配置一种资源,但同一种资源可以给多个活动使用。
DDD的一个重要理念是,希望通过领域模型来分担一部分业务逻辑,实现复用并降低领域层业务接口的复杂度。需要尽量避免entity成为单纯的数据容器,mapper只进行字段映射不包含业务能力。以下主要通过动态资源(DynamicResource)的entity和mapper代码设计来举例展开。
entity代码示例
在领域模型层,动态资源(DynamicResource)的类型是Interface,其他字段均为primitive或struct。设计新的特效类型的核心需求点就是创新性,和以前的特效必须有所不同,因而没法用统一的struct来定义。在Activity中我们把各种不同的特效抽象出DynamicResource这个Interface,再用每种特效独立的struct来实现。
在DTO中为了使通信协议具备可扩展性,动态资源字段使用了string类型的序列化json来传输。在领域层的业务代码中同样使用json表述复杂模型显然会大幅增加写bug的概率,因而需要具体定义,并在DTO到DO的转换mapper中具体解析出来。
此外,entity中的领域模型包含Validate, HasUserBlackList等成员方法,使自身的合法性校验以及其他基础业务能力内聚,避免分散在领域层Service的业务代码中造成不统一以及领域层职责过重。
type Activity struct {
ID int64
Name string
ClassifyId int32 // 资源模版id
State ActivityState // 状态
Type DimensionType // 投放类型
Dimensions []*ActivityDimension // 投放维度
DynamicResource resource.DynamicResource // 动态资源
// 其他业务字段
// ...
Ctime common.Time
Mtime common.Time
}
func (act *Activity) Validate() error {
// 验证activity和各个Dimension的字段映射关系
for _, d := range act.Dimensions {
if act.ID != d.ActivityId || act.State != d.State || act.Type != d.Type {
return ecode.Error(ecode.ParamInvalid, "dimension和activity字段不匹配")
}
}
return act.DynamicResource.Validate()
}
func (act *Activity) HasUserBlackList() bool {
return act.ClassifyId == resource.ClassifyActivityIcon
}
func (act *Activity) HasCidBlackList() bool {
return act.Type == dmActivity.Dimension_DimensionAll && act.ClassifyId == resource.ClassifyActivityIcon
}
mapper代码示例
一个业务代码中容易出现的情形是,mapper机械式地进行同名字段的映射比配,带来了额外的开发量却没有有效分担业务复杂度。理想情况下,mapper应该和entity类似,以内聚的方式承担部分轻量级的业务逻辑,从而实现领域层的关注点分离。
以下代码将弹幕活动(Activity)的DTO转化成DO。它先调用parseResource方法,根据资源模版id(classifyId)将DynamicResource从DTO中的json结构体映射到具体类型,完成部分基础字段校验并通过反射对公有字段进行赋值。然后补充剩余字段的映射并校验返回,这样使得从mapper中返回,即将传入领域层的DO模型一定保持合法。
mapper主体
func ActivityFromDTO(dto *pb.DmActivity) (*entity.Activity, error) {
dynamicResource, err := parseResource(dto.ClassifyId, dto.GetResource())
if err != nil {
return nil, err
}
act := &entity.Activity{
ID: dto.GetId(),
Name: dto.GetName(),
ClassifyId: dto.GetClassifyId(),
State: entity.ActivityState(dto.GetState()),
Type: entity.DimensionType(dto.GetType()),
Dimensions: ActivityDimensionListFromDTO(dto.GetDimension()),
DynamicResource: dynamicResource,
// 其他业务字段
// ...
Ctime: dto.GetCtime(),
Mtime: dto.GetMtime(),
}
// 校验数据完整性
if err = act.Validate(); err != nil {
return nil, err
}
return act, nil
}
模糊类型到具体类型
func parseResource(classifyId int32, resourceMeta string) (res resource.DynamicResource, err error) {
tmp, has := classifyResourceMap[classifyId]
if !has {
err = ecode.Error(ecode.NothingFound, "classifyResourceMap中不存在此classifyId")
return
}
if len(resourceMeta) == 0 {
err = ecode.Error(ecode.NothingFound, "resource字段为空")
return
}
t := reflect.TypeOf(tmp).Elem()
ptr := reflect.New(t)
ptr.Elem().FieldByName("ClassifyId").SetInt(int64(classifyId))
res, ok := ptr.Interface().(resource.DynamicResource)
if !ok {
err = ecode.Error(ecode.NothingFound, "classifyResourceMap reflect interface error")
return
}
if err = json.Unmarshal([]byte(resourceMeta), res); err != nil {
return
}
return
}
适用场景 & 难点 & 收益 & 成本
领域驱动设计显然不是银弹,它有典型的适用场景和优缺点,同时也有较高的落地成本。实际开发中需要根据自己业务的形态和发展阶段参考DDD的思想,并在工具层面进行trade off,避免因为某一个流行理论是这样说的就要原封不动去照搬实践。
适用场景
业务复杂、链路流程深,需要拆分关注点来降低理解成本
希望在多渠道来源的散点式的需求中沉淀平台能力,以提升效率和可扩展性
业务需要持续迭代
业务领域内需要多人分工协作
不适用场景
业务相对发展成熟,偶尔迭代大多数时间只需维护
业务本身复杂度低,主要挑战来自于高并发、高可用、强一致性等工程性能问题
业务目标、形态频繁变更,没法形成长期规划
经常发生研发团队的组织架构调整
难点
团队内:需要布道,统一认知难。一旦出现交接或组织架构调整,传承理念有难度
向上:说明价值困难,没有短期、实际、可量化收益,不好忽悠老板投资源
平级:统一语言、梳理领域事件都需要领域专家(通常是产品、运营等)参与,需要跨团队价值认可并构建良好的合作关系
收益
基于六边形架构和DDD,基本实现了层级内部的逻辑内聚以及不同层级间的关注点分离
例如:在新的结构下,多活、数据库迁移等工作可以只停留在DAO层,避免入侵业务层
结合了kratos的同步、异步服务拆分,解决了历史演进中的无序代码共享、复制粘贴和缺少规范的问题
在团队内统一认知,对业务领域划分、分包结构及部分编码规范建立共识
充血模型把数据校验和基础业务方法收拢到模型内部,降低了业务层的理解和迭代成本。将部分code review、测试中发现的问题左移到编码阶段解决,从根本上提升效率和稳定性
成本
代码中大量DTO, DO, PO的转换mapper需要额外工作量
建立共识的成本高,需要较为完备的设计文档和可执行的编码规范
代码改造成本高,需要较好的协作模式来解决过渡期业务需求开发产生额外成本
充血模型对设计者的技术能力、业务理解、沟通交流都有要求,需要团队能力培养和梯队建设
展望
领域服务拆分
上面的框架是基于一个子领域下只有一个handler, service, dao层级的情况。再进阶一步,当遇到流量较小的偏b端服务时,业务逻辑复杂但接口流量不大,此时拆分微服务会显著增加运维成本。但领域层独立之后业务逻辑复杂度仍然过重,怎么办?
可以通过垂直拆分领域服务的方式进一步拆分拆分复杂度。
例如下面这个订单系统的服务架构:在应用层分为负责下单和订单状态相关的两个Handler;在领域层分为订单、支付、配送三个Service;在持久层分为订单、用户、支付、配送四个Dao。
代码结构一致性的规模效应
前面的大部分收益集中在一个业务、团队内部。在部门、公司层面应用同一套微服务架构,可以达成规模效应获得更多的拓展空间和收益。
一些具体的例子:
形成包含统一规范、风格和最佳实践的标准化代码结构,不同业务间的理解和开发经验可快速平移。
面向各层级的公开接口,提供标准化的单元测试框架。
基础中间件升级和业务隔离,更容易做统一方案。
通过decorator实现中间件方法内嵌在每个层级的对外接口中。例如:分层的标准化监控面板、错误治理和trace标记等。线上问题定位、服务治理更加快捷清晰。
用gpt实现自动化Mapper构建
DDD分层带来的一个成本增加项是,每次新定义一个领域对象需要写很多mapper。它们大部分逻辑是简单的字段映射,同时也包含一部分层级间的改造。
这部分工作可以使用gpt开发工具实现80%的自动编写,开发人员再去补充特殊校验逻辑并检验。
以上是今天的分享内容,如果你有什么想法或疑问,欢迎大家在留言区与我们互动,如果喜欢本期内容的话,欢迎点个“在看”吧!
往期精彩指路